using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using ScrewTurn.Wiki.PluginFramework;

namespace ScrewTurn.Wiki.PluginPack
{
    /// <summary>
    /// Implements a Basic Statistics Plugin.
    /// </summary>
    public class BasicStatsPlugin : IFormatterProvider
    {
        private const string HitsFile = "Hits.cs";
        private readonly object fileLock = new object();

        private readonly ComponentInformation info = new ComponentInformation("Basic Statistics Plugin 1.3.3",
                                                                              "ScrewTurn Software",
                                                                              "http://www.screwturn.eu");

        private readonly Regex statsRegex = new Regex("\\{stats\\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);

        private List<string> botSignatures;
        private int cacheSize = 50;
        private string config;
        private List<string> excludedPages;
        private List<string> excludedUsers;
        private List<ExtendedPageHit> hits;
        private IHost host;
        private string magicWord;
        private int maxLanguages = 5;
        private int maxSessions = 50;
        private bool nobots = true;
        private bool publicAccess;
        private int sessionsDivHeight = 300;

        #region IFormatterProvider Members

        public void Init(IHost host, string config)
        {
            this.host = host;
            this.config = config;

            excludedUsers = new List<string>(10);
            botSignatures = new List<string>(30);
            PopulateExcludedUserAgents(botSignatures);
            excludedPages = new List<string>(10);

            DecodeConfig(this.config);

            if (!File.Exists(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile))
            {
                File.Create(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile);
            }

            hits = new List<ExtendedPageHit>(cacheSize);

            host.AddToolbarItem(ToolbarItem.SpecialTag, "{Stats}", "{STATS}");
        }

        public void Shutdown()
        {
            // Dump remaining hits on disk
            DumpHits();
        }

        public ComponentInformation Information
        {
            get { return info; }
        }

        public bool PerformPhase1
        {
            get { return false; }
        }

        public bool PerformPhase2
        {
            get { return false; }
        }

        public bool PerformPhase3
        {
            get { return true; }
        }

        public string Format(string raw, ContextInformation context, FormattingPhase phase)
        {
            if (phase == FormattingPhase.Phase3 && context.Page != null)
            {
                HttpRequest req = HttpContext.Current.Request;
                if (nobots && !IsBot() && !excludedUsers.Contains(context.Username) && !IsMagicWordEngaged(req))
                {
                    string referer = "";
                    try
                    {
                        // UrlReferrer sometimes throws an exception with no apparent reason
                        referer = req.UrlReferrer != null ? req.UrlReferrer.ToString() : "";
                    }
                    catch
                    {
                    }
                    // Store page hit
                    var h = new ExtendedPageHit(HttpContext.Current.Session.SessionID,
                                                req.UserHostAddress, context.Page.Name, referer,
                                                req.UserAgent != null ? req.UserAgent : "",
                                                PrepareUserLanguage(req.UserLanguages),
                                                context.Username != null ? context.Username : "", DateTime.Now);

                    lock (hits)
                    {
                        hits.Add(h);
                        if (hits.Count >= cacheSize) DumpHits();
                    }
                }
            }

            // Render the stats display, if needed and allowed
            Match m = statsRegex.Match(raw);
            if (m.Success)
            {
                var sb = new StringBuilder(raw);
                sb.Remove(m.Index, m.Length);
                if (context.UserIsAdmin || publicAccess)
                {
                    sb.Insert(m.Index, RenderStats(context.UserIsAdmin));
                }
                return sb.ToString();
            }
            else return raw;
        }

        #endregion

        /// <summary>
        /// Validates and decodes the configuration.
        /// </summary>
        /// <param name="config">The configuration.</param>
        private void DecodeConfig(string config)
        {
            if (string.IsNullOrEmpty(config)) return;

            string[] lines = config.Replace("\r", "").Split(new[] {'\n'}, StringSplitOptions.RemoveEmptyEntries);

            string[] fields;
            foreach (string line in lines)
            {
                if (!line.Contains("=")) continue;

                fields = line.Split('=');

                try
                {
                    switch (fields[0].ToLowerInvariant().Trim())
                    {
                        case "cachesize":
                            cacheSize = int.Parse(fields[1].Trim());
                            break;
                        case "publicaccess":
                            publicAccess = bool.Parse(fields[1].Trim());
                            break;
                        case "maxsessions":
                            maxSessions = int.Parse(fields[1].Trim());
                            break;
                        case "nobots":
                            nobots = bool.Parse(fields[1].Trim());
                            break;
                        case "exclude":
                            excludedUsers.Add(fields[1].Trim());
                            break;
                        case "magicword":
                            magicWord = fields[1].Trim();
                            break;
                        case "maxlanguages":
                            maxLanguages = int.Parse(fields[1].Trim());
                            break;
                        case "botsignature":
                            botSignatures.Add(fields[1].Trim().ToLowerInvariant());
                            break;
                        case "dontshowpage":
                            excludedPages.Add(fields[1].Trim().ToLower(CultureInfo.CurrentCulture));
                            break;
                        case "dontshowmainpage":
                            if (bool.Parse(fields[1].Trim()))
                                excludedPages.Add(
                                    host.GetSettingValue(SettingName.DefaultPage).ToLower(CultureInfo.CurrentCulture));
                            break;
                        case "sessionsdivheight":
                            sessionsDivHeight = int.Parse(fields[1].Trim());
                            break;
                    }
                }
                catch
                {
                    // Any exception is thrown by a parse failure
                    throw new InvalidOperationException();
                }
            }
        }

        private void PopulateExcludedUserAgents(List<string> ua)
        {
            ua.AddRange(new[] {"googlebot", "msnbot", "slurp", "ia_archiver", "tailrank", "searchme"});
            ua.AddRange(new[] {"msrbot", "ask jeeves", "techrigybot", "snapbot", "daum rss robot"});
            ua.AddRange(new[] {"suchen.de", "vadixbot", "pagebull", "missigua locator", "sygolbot"});
            ua.AddRange(new[] {"mj12bot", "wells search ii", "igdespyder", "yahooseeker"});
            ua.AddRange(new[] {"kalooga.com", "powerset.com"});
            ua.AddRange(new[] {"spider", "crawler", "bot"});
        }

        private string PrepareUserLanguage(string[] langs)
        {
            if (langs == null || langs.Length == 0) return "";
            string ul = langs[0];
            if (ul.Contains(";"))
            {
                return ul.Substring(0, ul.IndexOf(";"));
            }
            else return ul;
        }

        private bool IsMagicWordEngaged(HttpRequest req)
        {
            if (string.IsNullOrEmpty(magicWord)) return false;
            else return !string.IsNullOrEmpty(req[magicWord]);
        }

        private bool IsBot()
        {
            string ua = HttpContext.Current.Request.UserAgent;
            if (string.IsNullOrEmpty(ua)) return false;

            ua = ua.ToLowerInvariant();
            return botSignatures.Exists(delegate(string s) { return ua.Contains(s); });
        }

        /// <summary>
        /// Dumps all the Page Hits on disk.
        /// </summary>
        private void DumpHits()
        {
            lock (hits)
            {
                var sb = new StringBuilder(hits.Count*50);
                foreach (ExtendedPageHit hit in hits)
                {
                    sb.Append(hit.Serialize());
                    sb.Append("\r\n");
                }
                lock (fileLock)
                {
                    File.AppendAllText(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile, sb.ToString());
                }
                hits.Clear();
            }
        }

        /// <summary>
        /// Renders the statistics to XHTML.
        /// </summary>
        /// <returns>The XHTML.</returns>
        private string RenderStats(bool admin)
        {
            // 1. Load all hits, memory then file
            // 2. Identify sessions
            // 3. Sort in-session hits
            // 4. Render

            // Dictionary (hashtable) to increase performance
            var tempSessions = new Dictionary<string, SessionInformation>(1000);

            lock (hits)
            {
                foreach (ExtendedPageHit hit in hits)
                {
                    if (!tempSessions.ContainsKey(hit.SessionID))
                    {
                        tempSessions.Add(hit.SessionID,
                                         new SessionInformation(hit.SessionID, hit.IP, hit.UserAgent, hit.Language,
                                                                hit.Username));
                    }
                    tempSessions[hit.SessionID].Hits.Add(new PageHit(hit.Page, hit.Referer, hit.DateTime));
                }
            }

            lock (fileLock)
            {
                // Read the file line-by-line to reduce memory usage

                var fs = new FileStream(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile, FileMode.Open,
                                        FileAccess.Read, FileShare.Read);
                var reader = new StreamReader(fs);

                while (!reader.EndOfStream)
                {
                    try
                    {
                        ExtendedPageHit h = ExtendedPageHit.Deserialize(reader.ReadLine().Trim('\r', '\n'));
                        if (!tempSessions.ContainsKey(h.SessionID))
                        {
                            tempSessions.Add(h.SessionID,
                                             new SessionInformation(h.SessionID, h.IP, h.UserAgent, h.Language,
                                                                    h.Username));
                        }
                        tempSessions[h.SessionID].Hits.Add(new PageHit(h.Page, h.Referer, h.DateTime));
                    }
                    catch
                    {
                        break;
                    }
                }

                reader.Close();
            }

            if (tempSessions.Count == 0) return "";

            // Build a list out of the dictionary, and sort hits and sessions
            var sessions = new List<SessionInformation>(tempSessions.Count);
            foreach (string key in tempSessions.Keys)
            {
                sessions.Add(tempSessions[key]);
                sessions[sessions.Count - 1].Hits.Sort();
            }
            sessions.Sort();

            TimeSpan life = sessions[sessions.Count - 1].DateTime - sessions[0].DateTime +
                            sessions[sessions.Count - 1].Duration; // Sessions are sorted asc

            var allHits = new Dictionary<string, int>(sessions.Count*5);
            var tempLanguages = new Dictionary<string, int>(10);
            double totalDuration = 0;

            var sessionsByHour = new int[6];
            var sessionsByDayOfWeek = new int[7];
            int m = 0;
            int y = DateTime.Now.Year - sessions[0].DateTime.Year;
            m = DateTime.Now.Month + y*12 - sessions[0].DateTime.Month + 1;
            if (m > 12) m = 12;
            var sessionsByMonth = new int[m];
            int windows = 0, linux = 0, mac = 0;
            int firefox = 0, ie = 0, safari = 0;

            // Render the results

            var sb = new StringBuilder(5000);
            sb.Append("<div id=\"StatsDiv\">%%%SSS%%%<br /><br /><b>Details for the last ");
            sb.Append(maxSessions.ToString());
            sb.AppendFormat(
                " Sessions</b><div id=\"SessionsDiv\" style=\"height: {0}px; overflow: auto; border: solid 1px #CCCCCC;\">",
                sessionsDivHeight);
            sb.Append("<table cellspacing=\"0\" cellpadding=\"2\">");
            sb.Append(
                "<tr style=\"background-color: #DDDDDD;\"><td><b>IP</b></td><td><b>User Agent</b></td><td><b>Language</b></td><td><b>Username</b></td><td><b>Date/Time</b></td><td><b>Duration</b></td><td><b>Hits</b></td></tr>");
            int cnt = 0;
            for (int i = sessions.Count - 1; i >= 0; i--)
            {
                if (cnt < maxSessions)
                {
                    // Limit # of displayed sessions
                    if (i%2 == 0) sb.Append("<tr><td>");
                    else sb.Append("<tr style=\"background-color: #F4F4F4;\"><td>");
                    if (admin) sb.Append(sessions[i].IP);
                    else sb.Append("***.***.***.***");
                    sb.Append("</td><td style=\"width: 400px; font-size: 9px;\">");
                    sb.Append(sessions[i].UserAgent);
                    sb.Append("</td><td>");
                    sb.Append(sessions[i].Language);
                    sb.Append("</td><td>");
                    if (!string.IsNullOrEmpty(sessions[i].Username))
                    {
                        if (admin) sb.Append(sessions[i].Username);
                        else sb.Append("****");
                    }
                    sb.Append("</td><td>");
                    sb.Append(
                        host.AlignDateTimeWithPreferences(sessions[i].DateTime).ToString(
                            host.GetSettingValue(SettingName.DateTimeFormat)));
                    sb.Append("</td><td>");
                    sb.Append(PrintTimeSpan(sessions[i].Duration, false, true));
                    sb.Append("</td><td>");
                    sb.Append(sessions[i].Hits.Count.ToString());
                    sb.Append("</td>");
                    sb.Append("</tr>");
                }

                sessionsByHour[sessions[i].DateTime.Hour/4]++;
                sessionsByDayOfWeek[DayOfWeekNumber(sessions[i].DateTime.DayOfWeek)]++;
                m = GetMonth(sessions[i].DateTime, sessionsByMonth.Length);
                if (m != -1) sessionsByMonth[m]++;

                if (!string.IsNullOrEmpty(sessions[i].Language))
                {
                    string lang = sessions[i].Language.ToLowerInvariant();
                    if (!tempLanguages.ContainsKey(lang))
                    {
                        tempLanguages.Add(lang, 0);
                    }
                    tempLanguages[lang]++;
                }

                if (!string.IsNullOrEmpty(sessions[i].UserAgent))
                {
                    string ua = sessions[i].UserAgent.ToLowerInvariant();
                    if (ua.Contains("windows")) windows++;
                    else if (ua.Contains("linux") || ua.Contains("unix")) linux++;
                    else if (ua.Contains("macintosh")) mac++;
                    if (ua.Contains("firefox")) firefox++;
                    else if (ua.Contains("ie")) ie++;
                    else if (ua.Contains("safari")) safari++;
                }

                // Store all hits
                foreach (PageHit hit in sessions[i].Hits)
                {
                    if (!allHits.ContainsKey(hit.Page)) allHits.Add(hit.Page, 0);
                    allHits[hit.Page]++;
                }

                cnt++;

                totalDuration += sessions[i].Duration.TotalSeconds;
            }
            sb.Append("</table>");
            sb.Append("</div>"); // SessionsDiv
            sb.Append("</div>"); // StatsDiv
            sb.Append(
                "<small><br />Generated with <a href=\"http://www.screwturn.eu/Plugins.ashx#BSP\" title=\"ScrewTurn Wiki Basic Statistics Plugin\">Basic Statistics Plugin</a>.</small>");

            // Sort pages by hit count
            var pages = new List<PageHitCount>(allHits.Count);
            int totalHits = 0;
            foreach (string page in allHits.Keys)
            {
                pages.Add(new PageHitCount(page, allHits[page]));
                totalHits += allHits[page];
            }
            allHits = null; // Cleanup memory
            // Sort in desc order
            pages.Sort(delegate(PageHitCount p1, PageHitCount p2) { return p2.HitCount.CompareTo(p1.HitCount); });

            // Generate languages sorted lists
            var languages = new List<KeyValuePair<string, int>>(tempLanguages.Count);
            foreach (string key in tempLanguages.Keys)
            {
                languages.Add(new KeyValuePair<string, int>(key, tempLanguages[key]));
            }
            tempLanguages = null; // Cleanup memory
            // Sort in desc order
            languages.Sort(
                delegate(KeyValuePair<string, int> kvp1, KeyValuePair<string, int> kvp2) { return kvp2.Value.CompareTo(kvp1.Value); });

            var sessionsDay = (int) Math.Ceiling(sessions.Count/life.TotalDays);
            int sessionsMonth = sessionsDay*30;
            var hitsDay = (int) Math.Ceiling(totalHits/life.TotalDays);
            int hitsMonth = hitsDay*30;

            var sb2 = new StringBuilder(500);

            sb2.Append("<table><tr><td style=\"vertical-align: top;\">");

            // Sessions by week chart
            int max = 0;
            for (int i = 0; i < sessionsByDayOfWeek.Length; i++)
            {
                if (sessionsByDayOfWeek[i] > max) max = sessionsByDayOfWeek[i];
            }
            sb2.AppendFormat(
                "<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Sessions/Weekday [%]</td></tr><tr>",
                sessionsByDayOfWeek.Length);
            for (int i = 0; i < sessionsByDayOfWeek.Length; i++)
            {
                sb2.AppendFormat(
                    "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                    (sessionsByDayOfWeek[i] > 0
                         ? ((sessionsByDayOfWeek[i]/(double) sessions.Count*100D)).ToString("N1")
                         : ""),
                    (int) (sessionsByDayOfWeek[i]*100D/max));
            }
            sb2.Append("</tr><tr>");
            for (int i = 0; i < sessionsByDayOfWeek.Length; i++)
            {
                sb2.AppendFormat(
                    "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>",
                    DayOfWeekString(i));
            }
            sb2.Append("</tr></table><br />");

            // Sessions by hour chart
            max = 0;
            for (int i = 0; i < sessionsByHour.Length; i++)
            {
                if (sessionsByHour[i] > max) max = sessionsByHour[i];
            }
            sb2.AppendFormat(
                "<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Sessions/Hour [%]</td></tr><tr>",
                sessionsByHour.Length);
            for (int i = 0; i < sessionsByHour.Length; i++)
            {
                sb2.AppendFormat(
                    "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                    (sessionsByHour[i] > 0 ? ((sessionsByHour[i]/(double) sessions.Count*100D)).ToString("N1") : ""),
                    (int) (sessionsByHour[i]*100D/max));
            }
            sb2.Append("</tr><tr>");
            for (int i = 0; i < sessionsByHour.Length; i++)
            {
                sb2.AppendFormat(
                    "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}-{1}</td>",
                    i*4, i*4 + 4);
            }
            sb2.Append("</tr></table><br />");

            // Sessions by month
            max = 0;
            for (int i = 0; i < sessionsByMonth.Length; i++)
            {
                if (sessionsByMonth[i] > max) max = sessionsByMonth[i];
            }
            sb2.AppendFormat(
                "<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Sessions/Month [%]</td></tr><tr>",
                sessionsByMonth.Length);
            for (int i = sessionsByMonth.Length - 1; i >= 0; i--)
            {
                sb2.AppendFormat(
                    "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                    (sessionsByMonth[i] > 0 ? ((sessionsByMonth[i]/(double) sessions.Count*100D)).ToString("N1") : ""),
                    (int) (sessionsByMonth[i]*100D/max));
            }
            sb2.Append("</tr><tr>");
            for (int i = sessionsByMonth.Length - 1; i >= 0; i--)
            {
                sb2.AppendFormat(
                    "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>",
                    MonthString(DateTime.Now.AddMonths(-i).Month));
            }
            sb2.Append("</tr></table><br />");

            sb2.Append("</td><td style=\"vertical-align: top;\">");

            sb2.Append("<table cellspacing=\"0\" cellpadding=\"2\">");
            sb2.Append(
                "<tr style=\"background-color: #DDDDDD;\"><td style=\"text-align: right;\"><b>Feature</b></td><td><b>Value</b></td></tr>");

            int idx = 0;
            sb2.Append(GenerateStatsTableRow("Total sessions", sessions.Count.ToString(), false, ref idx));
            sb2.Append(GenerateStatsTableRow("Total hits", totalHits.ToString(), false, ref idx));
            sb2.Append(GenerateStatsTableRow("Avg. sessions/month", sessionsMonth.ToString(), true, ref idx));
            sb2.Append(GenerateStatsTableRow("Avg. sessions/day", sessionsDay.ToString(), true, ref idx));
            sb2.Append(GenerateStatsTableRow("Avg. hits/month", hitsMonth.ToString(), true, ref idx));
            sb2.Append(GenerateStatsTableRow("Avg. hits/day", hitsDay.ToString(), true, ref idx));
            sb2.Append(GenerateStatsTableRow("Avg. hits/page", ((totalHits/pages.Count)).ToString(), true, ref idx));
            sb2.Append(GenerateStatsTableRow("Avg. hits/session", ((totalHits/sessions.Count)).ToString(), true, ref idx));
            //TimeSpan avgDuration = new TimeSpan((long)(totalDuration / sessions.Count * 10000000));
            sb2.Append(GenerateStatsTableRow("Avg. session duration", ((int) (totalDuration/sessions.Count/60)) + " min",
                                             true, ref idx));
            if (pages.Count >= 2)
            {
                PageHitCount top1 = GetMostRequestedPage(pages, 1);
                if (top1 != null)
                    sb2.Append(GenerateStatsTableRow("1st most requested page",
                                                     BuildPageLink(top1.Page) + " (" + top1.HitCount + " hits)", false,
                                                     ref idx));
                if (pages.Count > 2)
                {
                    PageHitCount top2 = GetMostRequestedPage(pages, 2);
                    if (top2 != null)
                        sb2.Append(GenerateStatsTableRow("2nd most requested page",
                                                         BuildPageLink(top2.Page) + " (" + top2.HitCount + " hits)",
                                                         false, ref idx));
                }
                if (pages.Count > 3)
                {
                    PageHitCount top3 = GetMostRequestedPage(pages, 3);
                    if (top3 != null)
                        sb2.Append(GenerateStatsTableRow("3rd most requested page",
                                                         BuildPageLink(top3.Page) + " (" + top3.HitCount + " hits)",
                                                         false, ref idx));
                }
                PageHitCount bottom = GetLeastRequestedPage(pages);
                if (bottom != null)
                    sb2.Append(GenerateStatsTableRow("Least requested page",
                                                     BuildPageLink(bottom.Page) + " (" + bottom.HitCount + " hits)",
                                                     false, ref idx));
            }
            sb2.Append(GenerateStatsTableRow("Statistics life", PrintTimeSpan(life, true, false), false, ref idx));

            sb2.Append("</table><br />");

            // Operating systems/browsers
            max = Math.Max(windows, Math.Max(linux, Math.Max(mac, Math.Max(ie, Math.Max(firefox, safari)))));
            sb2.AppendFormat(
                "<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"6\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Visitors' OS/Browser [%]</td></tr><tr>");
            sb2.AppendFormat(
                "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                (windows > 0 ? ((windows/(double) sessions.Count*100D)).ToString("N1") : ""),
                (int) (windows*100D/max));
            sb2.AppendFormat(
                "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                (linux > 0 ? ((linux/(double) sessions.Count*100D)).ToString("N1") : ""),
                (int) (linux*100D/max));
            sb2.AppendFormat(
                "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                (mac > 0 ? ((mac/(double) sessions.Count*100D)).ToString("N1") : ""),
                (int) (mac*100D/max));
            sb2.AppendFormat(
                "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                (ie > 0 ? ((ie/(double) sessions.Count*100D)).ToString("N1") : ""),
                (int) (ie*100D/max));
            sb2.AppendFormat(
                "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                (firefox > 0 ? ((firefox/(double) sessions.Count*100D)).ToString("N1") : ""),
                (int) (firefox*100D/max));
            sb2.AppendFormat(
                "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                (safari > 0 ? ((safari/(double) sessions.Count*100D)).ToString("N1") : ""),
                (int) (safari*100D/max));
            sb2.Append("</tr><tr>");
            sb2.AppendFormat(
                "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "Win");
            sb2.AppendFormat(
                "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "*nix");
            sb2.AppendFormat(
                "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "Mac");
            sb2.AppendFormat(
                "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "IE");
            sb2.AppendFormat(
                "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "FF");
            sb2.AppendFormat(
                "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "Saf.");
            sb2.Append("</tr></table><br />");

            // Languages
            max = 0;
            for (int i = 0; i < languages.Count; i++)
            {
                if (languages[i].Value > max) max = languages[i].Value;
            }
            int len = Math.Min(maxLanguages, languages.Count);
            sb2.AppendFormat(
                "<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Visitors' Language [%]</td></tr><tr>",
                len);
            for (int i = 0; i < len; i++)
            {
                sb2.AppendFormat(
                    "<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
                    (languages[i].Value > 0 ? ((languages[i].Value/(double) sessions.Count*100D)).ToString("N1") : ""),
                    (int) (languages[i].Value*100D/max));
            }
            sb2.Append("</tr><tr>");
            for (int i = 0; i < len; i++)
            {
                sb2.AppendFormat(
                    "<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>",
                    languages[i].Key);
            }
            sb2.Append("</tr></table>");

            sb2.Append("</td></tr></table>");

            sb.Replace("%%%SSS%%%", sb2.ToString());

            return sb.ToString();
        }

        private string BuildPageLink(string name)
        {
            return string.Format("<a class=\"pagelink\" href=\"{0}.ashx\" title=\"{0}\">{0}</a>", name);
        }

        private int GetMonth(DateTime dateTime, int len)
        {
            int y = DateTime.Now.Year - dateTime.Year;
            int m = DateTime.Now.Month + 12*y - dateTime.Month;
            if (m > len) return -1;
            else return m;
        }

        private PageHitCount GetMostRequestedPage(List<PageHitCount> pages, int rank)
        {
            int idx = -1;
            for (int i = 0; i < rank; i++)
            {
                idx++;
                // Jump all excluded pages
                while (idx < pages.Count - 1 &&
                       excludedPages.Contains(pages[idx].Page.ToLower(CultureInfo.CurrentCulture))) idx++;
            }
            if (idx > pages.Count - 1) return null;
            return pages[idx];
        }

        private PageHitCount GetLeastRequestedPage(List<PageHitCount> pages)
        {
            int idx = pages.Count - 1;
            if (idx < 0) return null;
            while (idx > 0 && excludedPages.Contains(pages[idx].Page.ToLower(CultureInfo.CurrentCulture))) idx--;
            if (idx < 0) return null;
            return pages[idx];
        }

        private string PrintTimeSpan(TimeSpan ts, bool days, bool seconds)
        {
            var sb = new StringBuilder(20);
            if (days) sb.AppendFormat("{0}d ", ts.Days);
            sb.AppendFormat("{0}h {1}m", ts.Hours, ts.Minutes);
            if (seconds) sb.AppendFormat(" {0}s", ts.Seconds);
            return sb.ToString();
        }

        private string GenerateStatsTableRow(string cell1, string cell2, bool tilde, ref int idx)
        {
            var sb = new StringBuilder(100);
            if (idx%2 == 0) sb.Append("<tr>");
            else sb.Append("<tr style=\"background-color: #F4F4F4;\">");
            sb.Append("<td style=\"text-align: right;\">");
            sb.Append(cell1);
            sb.Append("</td><td><b>");
            if (tilde) sb.Append("~");
            sb.Append(cell2);
            sb.Append("</b></td></tr>");
            idx++;
            return sb.ToString();
        }

        private int DayOfWeekNumber(DayOfWeek dow)
        {
            switch (dow)
            {
                case DayOfWeek.Monday:
                    return 0;
                case DayOfWeek.Tuesday:
                    return 1;
                case DayOfWeek.Wednesday:
                    return 2;
                case DayOfWeek.Thursday:
                    return 3;
                case DayOfWeek.Friday:
                    return 4;
                case DayOfWeek.Saturday:
                    return 5;
                case DayOfWeek.Sunday:
                    return 6;
                default:
                    throw new ArgumentException("invalid day of week", "dow");
            }
        }

        private string DayOfWeekString(int dow)
        {
            switch (dow)
            {
                case 0:
                    return "mon";
                case 1:
                    return "tue";
                case 2:
                    return "wed";
                case 3:
                    return "thu";
                case 4:
                    return "fri";
                case 5:
                    return "sat";
                case 6:
                    return "sun";
                default:
                    throw new ArgumentException("Invalid day of week", "dow");
            }
        }

        private string MonthString(int month)
        {
            switch (month)
            {
                case 1:
                    return "jan";
                case 2:
                    return "feb";
                case 3:
                    return "mar";
                case 4:
                    return "apr";
                case 5:
                    return "may";
                case 6:
                    return "jun";
                case 7:
                    return "jul";
                case 8:
                    return "aug";
                case 9:
                    return "sep";
                case 10:
                    return "oct";
                case 11:
                    return "nov";
                case 12:
                    return "dec";
                default:
                    throw new ArgumentException("Invalid month", "month");
            }
        }
    }

    /// <summary>
    /// Contains basic information about a Page Hit.
    /// </summary>
    public class PageHit : IComparable<PageHit>
    {
        protected DateTime dateTime;
        protected string page, referer;

        /// <summary>
        /// Initializes a new instance of the <b>PageHit</b> class.
        /// </summary>
        /// <param name="page">The requested Wiki page.</param>
        /// <param name="dateTime">The date/time.</param>
        public PageHit(string page, string referer, DateTime dateTime)
        {
            this.page = page;
            this.dateTime = dateTime;
        }

        /// <summary>
        /// Gets or sets the requested Wiki Page.
        /// </summary>
        public string Page
        {
            get { return page; }
            set { page = value; }
        }

        /// <summary>
        /// Gets or the date/time.
        /// </summary>
        public DateTime DateTime
        {
            get { return dateTime; }
            set { dateTime = value; }
        }

        #region IComparable<PageHit> Members

        public int CompareTo(PageHit other)
        {
            return dateTime.CompareTo(other.dateTime);
        }

        #endregion
    }

    /// <summary>
    /// Represents a Page Hit.
    /// </summary>
    public class ExtendedPageHit : PageHit
    {
        private readonly string username;
        private string ip;
        private string language;
        private string sessionId;
        private string userAgent;

        /// <summary>
        /// Initializes a new instance of the <b>ExtendedPageHit</b> class.
        /// </summary>
        /// <param name="sessionId">The Session ID.</param>
        /// <param name="ip">The IP Address.</param>
        /// <param name="page">The requested Wiki Page.</param>
        /// <param name="referer">The referer, if any.</param>
        /// <param name="userAgent">The user agent, if any.</param>
        /// <param name="language">The user language, if any.</param>
        /// <param name="username">The username, if any.</param>
        /// <param name="dateTime">The date/time.</param>
        public ExtendedPageHit(string sessionId, string ip, string page, string referer,
                               string userAgent, string language, string username, DateTime dateTime)
            : base(page, referer, dateTime)
        {
            this.sessionId = sessionId;
            this.ip = ip;
            this.userAgent = userAgent;
            this.language = language;
            this.username = username;
        }

        /// <summary>
        /// Gets or sets the Session ID.
        /// </summary>
        public string SessionID
        {
            get { return sessionId; }
            set { sessionId = value; }
        }

        /// <summary>
        /// Gets or sets the IP Address.
        /// </summary>
        public string IP
        {
            get { return ip; }
            set { ip = value; }
        }

        /// <summary>
        /// Gets or sets the referer, if any.
        /// </summary>
        public string Referer
        {
            get { return referer; }
            set { referer = value; }
        }

        /// <summary>
        /// Gets or sets the user agent, if any.
        /// </summary>
        public string UserAgent
        {
            get { return userAgent; }
            set { userAgent = value; }
        }

        /// <summary>
        /// Gets or sets the user language, if any.
        /// </summary>
        public string Language
        {
            get { return language; }
            set { language = value; }
        }

        /// <summary>
        /// Gets or sets the Username, if any.
        /// </summary>
        public string Username
        {
            get { return username; }
            set { userAgent = value; }
        }

        /// <summary>
        /// Gets a string containing the current Page Hit instance for persistent storage.
        /// </summary>
        /// <returns>The string.</returns>
        public string Serialize()
        {
            // Format
            // <SessionID>|<IP>|<Page>|<Referer>|<UserAgent>|<Username>
            var sb = new StringBuilder(50);
            sb.Append(sessionId);
            sb.Append("|");
            sb.Append(ip);
            sb.Append("|");
            sb.Append(SanitizeString(page));
            sb.Append("|");
            if (!string.IsNullOrEmpty(referer)) sb.Append(SanitizeString(referer));
            else sb.Append("-");
            sb.Append("|");
            if (!string.IsNullOrEmpty(userAgent)) sb.Append(SanitizeString(userAgent));
            else sb.Append("-");
            sb.Append("|");
            if (!string.IsNullOrEmpty(language)) sb.Append(SanitizeString(language));
            else sb.Append("-");
            sb.Append("|");
            if (!string.IsNullOrEmpty(username)) sb.Append(SanitizeString(username));
            else sb.Append("-");
            sb.Append("|");
            sb.Append(dateTime.ToString("yyyy'/'MM'/'dd' 'HH':'mm':'ss"));
            return sb.ToString();
        }

        private string SanitizeString(string s)
        {
            return s.Replace("\r", "").Replace("\r", "").Replace("|", "");
        }

        #region Static Methods

        /// <summary>
        /// Deserializes a string containing a Page Hit instance.
        /// </summary>
        /// <param name="data">The string.</param>
        /// <returns>The instance.</returns>
        public static ExtendedPageHit Deserialize(string data)
        {
            string[] fields = data.Split('|');
            return new ExtendedPageHit(fields[0], fields[1], fields[2],
                                       fields[3] != "-" ? fields[3] : "",
                                       fields[4] != "-" ? fields[4] : "",
                                       fields[5] != "-" ? fields[5] : "",
                                       fields[6] != "-" ? fields[6] : "",
                                       DateTime.Parse(fields[7]));
        }

        #endregion
    }

    /// <summary>
    /// Contains information about a browsing session.
    /// </summary>
    public class SessionInformation : IComparable<SessionInformation>
    {
        private readonly string username;
        private List<PageHit> hits;
        private string userAgent;

        /// <summary>
        /// Initializes a new instance of the <b>SessionInformation</b> class.
        /// </summary>
        /// <param name="sessionId">The Session ID.</param>
        /// <param name="ip">The IP Address.</param>
        /// <param name="userAgent">The user agent, if any.</param>
        /// <param name="language">The user language, if any.</param>
        /// <param name="username">The username, if any.</param>
        /// <param name="hits">The Page Hits of the Session.</param>
        public SessionInformation(string sessionId, string ip, string userAgent,
                                  string language, string username, List<PageHit> hits)
        {
            this.SessionID = sessionId;
            this.IP = ip;
            this.userAgent = userAgent;
            this.Language = language;
            this.username = username;
            this.hits = hits;
        }

        /// <summary>
        /// Initializes a new instance of the <b>SessionInformation</b> class.
        /// </summary>
        /// <param name="sessionId">The Session ID.</param>
        /// <param name="ip">The IP Address.</param>
        /// <param name="userAgent">The user agent, if any.</param>
        /// <param name="language">The user language, if any.</param>
        /// <param name="username">The username, if any.</param>
        public SessionInformation(string sessionId, string ip, string userAgent, string language, string username)
            : this(sessionId, ip, userAgent, language, username, new List<PageHit>(20))
        {
        }

        /// <summary>
        /// Gets or sets the Session ID.
        /// </summary>
        public string SessionID { get; set; }

        /// <summary>
        /// Gets or sets the IP Address.
        /// </summary>
        public string IP { get; set; }

        /// <summary>
        /// Gets or sets the user agent, if any.
        /// </summary>
        public string UserAgent
        {
            get { return userAgent; }
            set { userAgent = value; }
        }

        /// <summary>
        /// Gets or sets the user language, if any.
        /// </summary>
        public string Language { get; set; }

        /// <summary>
        /// Gets or sets the Username, if any.
        /// </summary>
        public string Username
        {
            get { return username; }
            set { userAgent = value; }
        }

        /// <summary>
        /// Gets or sets the page hits.
        /// </summary>
        public List<PageHit> Hits
        {
            get { return hits; }
            set { hits = value; }
        }

        /// <summary>
        /// Gets the date/time of the first hit in the session.
        /// </summary>
        public DateTime DateTime
        {
            get { return hits[0].DateTime; }
        }

        /// <summary>
        /// Gets the estimated duration of the session.
        /// </summary>
        public TimeSpan Duration
        {
            get
            {
                if (hits.Count > 1)
                {
                    return hits[hits.Count - 1].DateTime.Subtract(hits[0].DateTime);
                }
                else return new TimeSpan(0L);
            }
        }

        /// <summary>
        /// Gets the estimated average interval between page hits.
        /// </summary>
        public TimeSpan AverageHitInterval
        {
            get
            {
                if (hits.Count > 1)
                {
                    double tot = 0;
                    for (int i = 0; i < hits.Count - 1; i++)
                    {
                        tot += hits[i + 1].DateTime.Subtract(hits[i].DateTime).TotalSeconds;
                    }
                    return new TimeSpan((long) (tot/(hits.Count - 1))*10000000L);
                }
                else return new TimeSpan(0L);
            }
        }

        #region IComparable<SessionInformation> Members

        public int CompareTo(SessionInformation other)
        {
            return DateTime.CompareTo(other.DateTime);
        }

        #endregion
    }

    /// <summary>
    /// Contains information about the hit count for a Page.
    /// </summary>
    public class PageHitCount
    {
        /// <summary>
        /// Initializes a new instance of the <b>PageHitCount</b> class.
        /// </summary>
        /// <param name="page">The Page.</param>
        /// <param name="hitCount">The Hit count.</param>
        public PageHitCount(string page, int hitCount)
        {
            this.Page = page;
            this.HitCount = hitCount;
        }

        /// <summary>
        /// Gets or sets the Page.
        /// </summary>
        public string Page { get; set; }

        /// <summary>
        /// Gets or sets the Hit count.
        /// </summary>
        public int HitCount { get; set; }
    }
}